@rubytech/create-realagent 1.0.680 → 1.0.681
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +209 -39
- package/package.json +1 -1
- package/payload/platform/plugins/docs/references/deployment.md +4 -2
- package/payload/platform/plugins/docs/references/troubleshooting.md +2 -0
- package/payload/platform/scripts/vnc.sh +12 -409
- package/payload/platform/templates/dotfiles/.tmux.conf +1 -0
- package/payload/platform/templates/systemd/maxy-ttyd.service +25 -0
- package/payload/server/maxy-edge.js +366 -5
- package/payload/server/public/assets/admin-CIkyOur7.js +362 -0
- package/payload/server/public/assets/admin-kHJ-D0s7.css +1 -0
- package/payload/server/public/index.html +2 -1
- package/payload/server/server.js +108 -301
- package/payload/server/public/assets/admin-BBL1no_g.js +0 -352
package/dist/index.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
3
3
|
import { existsSync, mkdirSync, writeFileSync, cpSync, readFileSync, rmSync, readdirSync, appendFileSync, openSync, closeSync, chmodSync, symlinkSync, unlinkSync, lstatSync, readlinkSync, accessSync, constants as fsConstants } from "node:fs";
|
|
4
4
|
import { resolve, join, dirname } from "node:path";
|
|
5
|
-
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
6
|
+
import { TTYD_VERSION, TTYD_SHA256_BY_ARCH, mapUnameToTtydArch, ttydDownloadUrl, } from "./pinned-binaries.js";
|
|
6
7
|
const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
|
|
7
8
|
// Brand manifest — read from payload to derive all brand-specific installation values.
|
|
8
9
|
// The bundler stamps brand.json into the payload at build time.
|
|
@@ -332,6 +333,7 @@ function pkgsMissing(pkgs) {
|
|
|
332
333
|
function installAptGroup(label, pkgs) {
|
|
333
334
|
const pairs = pkgs.map((original) => ({ original, resolved: resolveAptName(original) }));
|
|
334
335
|
logFile(` apt install (${label}): ${pairs.map((x) => x.resolved).join(" ")}`);
|
|
336
|
+
console.log(" [privileged] apt-get install");
|
|
335
337
|
shell("apt-get", ["install", "-y", ...pairs.map((x) => x.resolved)], { sudo: true });
|
|
336
338
|
const stillMissing = pairs.filter(({ resolved }) => {
|
|
337
339
|
const r = spawnSync("dpkg", ["-s", resolved], { stdio: "pipe", timeout: 5_000 });
|
|
@@ -364,8 +366,12 @@ function installSystemDeps() {
|
|
|
364
366
|
// assertion in vnc.sh check_window_on_display, closing the silent-fail
|
|
365
367
|
// class where PID is alive but no window is mapped on the target display.
|
|
366
368
|
const VNC_DEPS = ["tigervnc-standalone-server", "python3-websockify", "novnc", "xdg-utils", "chromium", "xterm", "xdotool"];
|
|
369
|
+
// Task 657: tmux powers the byte-stream admin terminal. ttyd attaches the
|
|
370
|
+
// shared `maxy-pty` tmux session, so scrollback survives WS reconnects and
|
|
371
|
+
// the same session is reused by the header overlay + upgrade modal.
|
|
372
|
+
const TERMINAL_DEPS = ["tmux"];
|
|
367
373
|
const WIFI_DEPS = ["hostapd", "dnsmasq"];
|
|
368
|
-
const ALL_APT_DEPS = [...BASE_DEPS, ...VNC_DEPS, ...WIFI_DEPS];
|
|
374
|
+
const ALL_APT_DEPS = [...BASE_DEPS, ...VNC_DEPS, ...TERMINAL_DEPS, ...WIFI_DEPS];
|
|
369
375
|
// Task 634 — verify the "deps are present" assumption with `dpkg -s` instead
|
|
370
376
|
// of asserting it (feedback_loud_failures.md). The previous silent-skip
|
|
371
377
|
// branch was benign until Task 632 added xdotool (the first new apt dep
|
|
@@ -388,6 +394,7 @@ function installSystemDeps() {
|
|
|
388
394
|
}
|
|
389
395
|
console.log(` Missing apt deps (${missing.length}): ${missing.join(", ")}`);
|
|
390
396
|
console.log(` Installing via sudo apt-get — sudo may prompt for your password...`);
|
|
397
|
+
console.log(" [privileged] apt-get update");
|
|
391
398
|
shell("apt-get", ["update"], { sudo: true });
|
|
392
399
|
installAptGroup("base utilities", BASE_DEPS);
|
|
393
400
|
installAptGroup("VNC stack", VNC_DEPS);
|
|
@@ -402,9 +409,12 @@ function installSystemDeps() {
|
|
|
402
409
|
// --hostname flag: set unconditionally, no detection, no preservation logic.
|
|
403
410
|
console.log(` Hostname: ${HOSTNAME_FLAG} (from --hostname flag)`);
|
|
404
411
|
try {
|
|
412
|
+
console.log(" [privileged] hostnamectl set-hostname");
|
|
405
413
|
shell("hostnamectl", ["set-hostname", HOSTNAME_FLAG], { sudo: true });
|
|
414
|
+
console.log(" [privileged] sed -i");
|
|
406
415
|
shell("sed", ["-i", `s/127\\.0\\.1\\.1.*$/127.0.1.1\\t${HOSTNAME_FLAG}/`, "/etc/hosts"], { sudo: true });
|
|
407
416
|
try {
|
|
417
|
+
console.log(" [privileged] sed -i");
|
|
408
418
|
shell("sed", ["-i", `s/^[#]*host-name=.*/host-name=${HOSTNAME_FLAG}/`, "/etc/avahi/avahi-daemon.conf"], { sudo: true });
|
|
409
419
|
console.log(` Avahi host-name: ${HOSTNAME_FLAG} (updated avahi-daemon.conf)`);
|
|
410
420
|
}
|
|
@@ -455,9 +465,12 @@ function installSystemDeps() {
|
|
|
455
465
|
console.log(` Hostname: ${BRAND.hostname} (${reason})`);
|
|
456
466
|
hostnameSetAttempted = true;
|
|
457
467
|
try {
|
|
468
|
+
console.log(" [privileged] hostnamectl set-hostname");
|
|
458
469
|
shell("hostnamectl", ["set-hostname", BRAND.hostname], { sudo: true });
|
|
470
|
+
console.log(" [privileged] sed -i");
|
|
459
471
|
shell("sed", ["-i", `s/127\\.0\\.1\\.1.*$/127.0.1.1\\t${BRAND.hostname}/`, "/etc/hosts"], { sudo: true });
|
|
460
472
|
try {
|
|
473
|
+
console.log(" [privileged] sed -i");
|
|
461
474
|
shell("sed", ["-i", `s/^[#]*host-name=.*/host-name=${BRAND.hostname}/`, "/etc/avahi/avahi-daemon.conf"], { sudo: true });
|
|
462
475
|
console.log(` Avahi host-name: ${BRAND.hostname} (updated avahi-daemon.conf)`);
|
|
463
476
|
}
|
|
@@ -500,8 +513,11 @@ function installSystemDeps() {
|
|
|
500
513
|
const avahiDestPath = `/etc/avahi/services/${BRAND.hostname}.service`;
|
|
501
514
|
try {
|
|
502
515
|
writeFileSync(avahiTmpPath, avahiService);
|
|
516
|
+
console.log(" [privileged] cp");
|
|
503
517
|
shell("cp", [avahiTmpPath, avahiDestPath], { sudo: true });
|
|
518
|
+
console.log(" [privileged] systemctl enable");
|
|
504
519
|
shell("systemctl", ["enable", "avahi-daemon"], { sudo: true });
|
|
520
|
+
console.log(" [privileged] systemctl restart");
|
|
505
521
|
shell("systemctl", ["restart", "avahi-daemon"], { sudo: true });
|
|
506
522
|
}
|
|
507
523
|
catch { /* not critical */ }
|
|
@@ -532,6 +548,7 @@ function installSystemDeps() {
|
|
|
532
548
|
if (existsSync("/usr/bin/nmcli") && !existsSync(nmConfFile)) {
|
|
533
549
|
console.log(" Disabling WiFi power save...");
|
|
534
550
|
writeFileSync(`/tmp/${BRAND.hostname}-no-powersave.conf`, "[connection]\nwifi.powersave = 2\n");
|
|
551
|
+
console.log(" [privileged] cp");
|
|
535
552
|
shell("cp", [`/tmp/${BRAND.hostname}-no-powersave.conf`, nmConfFile], { sudo: true });
|
|
536
553
|
spawnSync("sudo", ["systemctl", "restart", "NetworkManager"], { stdio: "pipe" });
|
|
537
554
|
}
|
|
@@ -549,6 +566,7 @@ function installNodejs() {
|
|
|
549
566
|
throw new Error("Automatic Node.js installation is only supported on Linux. Install Node.js 20+ manually.");
|
|
550
567
|
}
|
|
551
568
|
spawnSync("bash", ["-c", "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -"], { stdio: "inherit" });
|
|
569
|
+
console.log(" [privileged] apt-get install");
|
|
552
570
|
shell("apt-get", ["install", "-y", "nodejs"], { sudo: true });
|
|
553
571
|
}
|
|
554
572
|
function installClaudeCode() {
|
|
@@ -592,6 +610,7 @@ function installClaudeCode() {
|
|
|
592
610
|
}
|
|
593
611
|
else {
|
|
594
612
|
console.log(" This may take 15–30 minutes on Raspberry Pi...");
|
|
613
|
+
console.log(" [privileged] npm install -g @anthropic-ai/claude-code@latest");
|
|
595
614
|
shellRetry("npm", ["install", "-g", ...NPM_NET_FLAGS, "--loglevel", "verbose", "@anthropic-ai/claude-code@latest"], { sudo: true, timeout: 2_400_000 }, // 40 min — Pi downloads can take 25+ min
|
|
596
615
|
3, 30);
|
|
597
616
|
}
|
|
@@ -668,8 +687,10 @@ function resetNeo4jAuth(port = DEFAULT_NEO4J_PORT, dataDir = "/var/lib/neo4j") {
|
|
|
668
687
|
});
|
|
669
688
|
}
|
|
670
689
|
else {
|
|
690
|
+
console.log(" [privileged] neo4j-admin dbms");
|
|
671
691
|
shell("neo4j-admin", ["dbms", "set-initial-password", "--", password], { sudo: true });
|
|
672
692
|
}
|
|
693
|
+
console.log(" [privileged] systemctl start");
|
|
673
694
|
shell("systemctl", ["start", serviceName], { sudo: true });
|
|
674
695
|
console.log(" Waiting for Neo4j to start...");
|
|
675
696
|
for (let i = 0; i < 15; i++) {
|
|
@@ -797,11 +818,15 @@ function installNeo4j() {
|
|
|
797
818
|
const has17 = policyResult.status === 0 && !policyOutput.includes("Candidate: (none)");
|
|
798
819
|
const javaPackage = has17 ? "openjdk-17-jre-headless" : "openjdk-21-jre-headless";
|
|
799
820
|
console.log(` Installing Java (${javaPackage})...`);
|
|
821
|
+
console.log(" [privileged] apt-get install");
|
|
800
822
|
shell("apt-get", ["install", "-y", javaPackage], { sudo: true });
|
|
801
823
|
spawnSync("bash", ["-c", "curl -fsSL https://debian.neo4j.com/neotechnology.gpg.key | sudo gpg --yes --dearmor -o /usr/share/keyrings/neo4j.gpg 2>/dev/null"], { stdio: "inherit" });
|
|
802
824
|
spawnSync("bash", ["-c", 'echo "deb [signed-by=/usr/share/keyrings/neo4j.gpg] https://debian.neo4j.com stable 5" | sudo tee /etc/apt/sources.list.d/neo4j.list'], { stdio: "inherit" });
|
|
825
|
+
console.log(" [privileged] apt-get update");
|
|
803
826
|
shell("apt-get", ["update"], { sudo: true });
|
|
827
|
+
console.log(" [privileged] apt-get install");
|
|
804
828
|
shell("apt-get", ["install", "-y", "neo4j"], { sudo: true });
|
|
829
|
+
console.log(" [privileged] sed -i");
|
|
805
830
|
shell("sed", ["-i", "s/#server.default_listen_address=0.0.0.0/server.default_listen_address=127.0.0.1/", "/etc/neo4j/neo4j.conf"], { sudo: true });
|
|
806
831
|
// Generate strong random password — stored in persistent location (~/{configDir}/)
|
|
807
832
|
const password = randomBytes(24).toString("base64url");
|
|
@@ -812,8 +837,11 @@ function installNeo4j() {
|
|
|
812
837
|
const configDir = resolve(INSTALL_DIR, "platform/config");
|
|
813
838
|
mkdirSync(configDir, { recursive: true });
|
|
814
839
|
writeFileSync(join(configDir, ".neo4j-password"), password, { mode: 0o600 });
|
|
840
|
+
console.log(" [privileged] neo4j-admin dbms");
|
|
815
841
|
shell("neo4j-admin", ["dbms", "set-initial-password", "--", password], { sudo: true });
|
|
842
|
+
console.log(" [privileged] systemctl enable");
|
|
816
843
|
shell("systemctl", ["enable", "neo4j"], { sudo: true });
|
|
844
|
+
console.log(" [privileged] systemctl start");
|
|
817
845
|
shell("systemctl", ["start", "neo4j"], { sudo: true });
|
|
818
846
|
console.log(" Neo4j started. Password stored securely.");
|
|
819
847
|
}
|
|
@@ -851,11 +879,16 @@ function setupDedicatedNeo4j() {
|
|
|
851
879
|
throw new Error("/etc/neo4j/neo4j.conf not found. Cannot create dedicated instance without base config.");
|
|
852
880
|
}
|
|
853
881
|
// 1. Copy base config
|
|
882
|
+
console.log(" [privileged] cp -r");
|
|
854
883
|
shell("cp", ["-r", "/etc/neo4j", confDir], { sudo: true });
|
|
855
884
|
// 2. Modify config for this instance: bolt port, HTTP port, data/log directories
|
|
885
|
+
console.log(" [privileged] sed -i");
|
|
856
886
|
shell("sed", ["-i", `s/^#\\?server\\.bolt\\.listen_address=.*/server.bolt.listen_address=:${NEO4J_PORT}/`, `${confDir}/neo4j.conf`], { sudo: true });
|
|
887
|
+
console.log(" [privileged] sed -i");
|
|
857
888
|
shell("sed", ["-i", `s/^#\\?server\\.http\\.listen_address=.*/server.http.listen_address=:${httpPort}/`, `${confDir}/neo4j.conf`], { sudo: true });
|
|
889
|
+
console.log(" [privileged] sed -i");
|
|
858
890
|
shell("sed", ["-i", `s|^#\\?server\\.directories\\.data=.*|server.directories.data=${dataDir}/data|`, `${confDir}/neo4j.conf`], { sudo: true });
|
|
891
|
+
console.log(" [privileged] sed -i");
|
|
859
892
|
shell("sed", ["-i", `s|^#\\?server\\.directories\\.logs=.*|server.directories.logs=${logDir}|`, `${confDir}/neo4j.conf`], { sudo: true });
|
|
860
893
|
// Verify config was updated — sed silently no-ops if the key format changed
|
|
861
894
|
const confContent = spawnSync("grep", [`server.bolt.listen_address=:${NEO4J_PORT}`, `${confDir}/neo4j.conf`], { stdio: "pipe" });
|
|
@@ -864,7 +897,9 @@ function setupDedicatedNeo4j() {
|
|
|
864
897
|
logFile(` WARNING: sed verification failed — bolt port ${NEO4J_PORT} not found in ${confDir}/neo4j.conf`);
|
|
865
898
|
}
|
|
866
899
|
// 3. Create data and log directories
|
|
900
|
+
console.log(" [privileged] mkdir -p");
|
|
867
901
|
shell("mkdir", ["-p", `${dataDir}/data`, logDir], { sudo: true });
|
|
902
|
+
console.log(" [privileged] chown -R");
|
|
868
903
|
shell("chown", ["-R", "neo4j:neo4j", dataDir, logDir, confDir], { sudo: true });
|
|
869
904
|
// 4. Create systemd service
|
|
870
905
|
const serviceContent = `[Unit]
|
|
@@ -885,6 +920,7 @@ WantedBy=multi-user.target
|
|
|
885
920
|
`;
|
|
886
921
|
const tmpServicePath = `/tmp/${serviceName}.service`;
|
|
887
922
|
writeFileSync(tmpServicePath, serviceContent);
|
|
923
|
+
console.log(" [privileged] cp");
|
|
888
924
|
shell("cp", [tmpServicePath, `/etc/systemd/system/${serviceName}.service`], { sudo: true });
|
|
889
925
|
spawnSync("rm", ["-f", tmpServicePath]);
|
|
890
926
|
// 5. Set initial password before first start
|
|
@@ -901,7 +937,9 @@ WantedBy=multi-user.target
|
|
|
901
937
|
});
|
|
902
938
|
// 6. Enable and start the dedicated service
|
|
903
939
|
spawnSync("sudo", ["systemctl", "daemon-reload"], { stdio: "inherit" });
|
|
940
|
+
console.log(" [privileged] systemctl enable");
|
|
904
941
|
shell("systemctl", ["enable", serviceName], { sudo: true });
|
|
942
|
+
console.log(" [privileged] systemctl start");
|
|
905
943
|
shell("systemctl", ["start", serviceName], { sudo: true });
|
|
906
944
|
// 7. Verify connectivity — poll until cypher-shell can connect
|
|
907
945
|
console.log(` Waiting for dedicated Neo4j instance on port ${NEO4J_PORT}...`);
|
|
@@ -1046,6 +1084,7 @@ function installCloudflared() {
|
|
|
1046
1084
|
const arch = isArm64() ? "arm64" : "amd64";
|
|
1047
1085
|
const debPath = "/tmp/cloudflared.deb";
|
|
1048
1086
|
shellRetry("curl", ["-fSL", "--progress-bar", `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}.deb`, "-o", debPath], { timeout: 120_000 }, 3, 10);
|
|
1087
|
+
console.log(" [privileged] dpkg -i");
|
|
1049
1088
|
shell("dpkg", ["-i", debPath], { sudo: true });
|
|
1050
1089
|
spawnSync("rm", ["-f", debPath]);
|
|
1051
1090
|
}
|
|
@@ -1063,19 +1102,24 @@ function installWhisperCpp() {
|
|
|
1063
1102
|
return;
|
|
1064
1103
|
}
|
|
1065
1104
|
// Build dependencies — cmake is required since whisper.cpp migrated from plain make
|
|
1105
|
+
console.log(" [privileged] apt-get install");
|
|
1066
1106
|
shell("apt-get", ["install", "-y", "build-essential", "cmake"], { sudo: true });
|
|
1067
1107
|
// Clone or update the repository
|
|
1068
1108
|
if (!existsSync(WHISPER_DIR)) {
|
|
1069
1109
|
console.log(" Cloning whisper.cpp...");
|
|
1110
|
+
console.log(" [privileged] git clone");
|
|
1070
1111
|
shell("git", ["clone", "--depth", "1", "https://github.com/ggerganov/whisper.cpp.git", WHISPER_DIR], { sudo: true });
|
|
1071
1112
|
}
|
|
1072
1113
|
// Compile via cmake (whisper.cpp's Makefile is a thin cmake wrapper)
|
|
1073
1114
|
console.log(" Compiling whisper.cpp (this takes a few minutes on Pi)...");
|
|
1115
|
+
console.log(" [privileged] cmake -B");
|
|
1074
1116
|
shell("cmake", ["-B", "build"], { cwd: WHISPER_DIR, sudo: true, timeout: 120_000 });
|
|
1117
|
+
console.log(" [privileged] cmake --build");
|
|
1075
1118
|
shell("cmake", ["--build", "build", "--config", "Release", "-j2"], { cwd: WHISPER_DIR, sudo: true, timeout: 600_000 });
|
|
1076
1119
|
// Download the base model (~150MB)
|
|
1077
1120
|
if (!existsSync(WHISPER_MODEL)) {
|
|
1078
1121
|
console.log(" Downloading ggml-base model (~150MB)...");
|
|
1122
|
+
console.log(" [privileged] bash -c");
|
|
1079
1123
|
shellRetry("bash", ["-c", `cd ${WHISPER_DIR} && bash models/download-ggml-model.sh base`], { sudo: true, timeout: 300_000 }, 3, 15);
|
|
1080
1124
|
}
|
|
1081
1125
|
console.log(" whisper.cpp installed successfully.");
|
|
@@ -1690,56 +1734,180 @@ function installCrons() {
|
|
|
1690
1734
|
logFile(` crontab write failed: ${write.stderr}`);
|
|
1691
1735
|
}
|
|
1692
1736
|
}
|
|
1693
|
-
// Task
|
|
1694
|
-
// collapsed the
|
|
1695
|
-
//
|
|
1696
|
-
//
|
|
1697
|
-
//
|
|
1698
|
-
//
|
|
1699
|
-
//
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1737
|
+
// Task 657 restored the Task-591 ttyd/tmux pipeline after Task 645's
|
|
1738
|
+
// tear-down. Rationale: Task 643 collapsed the upgrade surface onto VNC, but
|
|
1739
|
+
// the RFB + X-focus path silently drops keystrokes at `[sudo] password for`.
|
|
1740
|
+
// The byte-stream surface (ttyd + tmux + xterm.js) is SSH-equivalent — the
|
|
1741
|
+
// operator's stated success case — and is now attached to `maxy-edge.service`
|
|
1742
|
+
// so the WS transport survives `systemctl --user restart maxy-ui` during an
|
|
1743
|
+
// in-browser upgrade (Task 647 invariant holds by construction).
|
|
1744
|
+
const TTYD_INSTALL_PATH = "/usr/local/bin/ttyd";
|
|
1745
|
+
function sha256File(path) {
|
|
1746
|
+
const hash = createHash("sha256");
|
|
1747
|
+
hash.update(readFileSync(path));
|
|
1748
|
+
return hash.digest("hex");
|
|
1749
|
+
}
|
|
1750
|
+
// Provision the upstream ttyd binary into /usr/local/bin/ttyd. Degrades with
|
|
1751
|
+
// a loud warning and a copy-pasteable remediation command on any failure —
|
|
1752
|
+
// never throws. Contract: the caller (installTerminalService) uses the
|
|
1753
|
+
// presence of TTYD_INSTALL_PATH after return to decide whether to enable the
|
|
1754
|
+
// maxy-ttyd.service systemd unit. ttyd is NOT in Debian Bookworm apt, so we
|
|
1755
|
+
// own the full download / verify / install flow here.
|
|
1756
|
+
function provisionTtydBinary() {
|
|
1757
|
+
const unameRaw = spawnSync("uname", ["-m"], { encoding: "utf-8", stdio: "pipe", timeout: 5_000 });
|
|
1758
|
+
const uname = (unameRaw.stdout || "").trim();
|
|
1759
|
+
const arch = mapUnameToTtydArch(uname);
|
|
1760
|
+
if (arch === null) {
|
|
1761
|
+
console.error(` WARNING: ttyd — unsupported architecture 'uname -m'='${uname}'. Admin terminal will be unavailable.`);
|
|
1762
|
+
console.error(` Remediate: install ttyd ${TTYD_VERSION} manually for your platform and place it at ${TTYD_INSTALL_PATH}, then 'sudo chmod +x ${TTYD_INSTALL_PATH}'.`);
|
|
1763
|
+
return false;
|
|
1704
1764
|
}
|
|
1705
|
-
const
|
|
1706
|
-
const
|
|
1707
|
-
|
|
1708
|
-
|
|
1765
|
+
const pinnedDigest = TTYD_SHA256_BY_ARCH[arch];
|
|
1766
|
+
const url = ttydDownloadUrl(arch);
|
|
1767
|
+
const remediation = `curl -L -o /tmp/ttyd.${arch} '${url}' && sudo mv /tmp/ttyd.${arch} ${TTYD_INSTALL_PATH} && sudo chmod +x ${TTYD_INSTALL_PATH}`;
|
|
1768
|
+
// Idempotency: existing binary with matching pinned digest → skip download.
|
|
1769
|
+
if (existsSync(TTYD_INSTALL_PATH)) {
|
|
1770
|
+
try {
|
|
1771
|
+
const existingDigest = sha256File(TTYD_INSTALL_PATH);
|
|
1772
|
+
if (existingDigest === pinnedDigest) {
|
|
1773
|
+
console.log(` ttyd ${TTYD_VERSION} already installed at ${TTYD_INSTALL_PATH} (SHA256 match — skipping download)`);
|
|
1774
|
+
return true;
|
|
1775
|
+
}
|
|
1776
|
+
console.log(` ttyd at ${TTYD_INSTALL_PATH} has different digest — replacing with pinned ${TTYD_VERSION}`);
|
|
1777
|
+
}
|
|
1778
|
+
catch (err) {
|
|
1779
|
+
console.error(` WARNING: could not read existing ${TTYD_INSTALL_PATH}: ${err instanceof Error ? err.message : String(err)} — will overwrite`);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
if (!canSudo()) {
|
|
1783
|
+
console.error(` WARNING: ttyd — sudo unavailable non-interactively, cannot write ${TTYD_INSTALL_PATH}. Admin terminal will be unavailable.`);
|
|
1784
|
+
console.error(` Remediate: ${remediation}`);
|
|
1785
|
+
return false;
|
|
1786
|
+
}
|
|
1787
|
+
const tmpPath = `/tmp/ttyd.${arch}`;
|
|
1788
|
+
try {
|
|
1789
|
+
console.log(` Downloading ttyd ${TTYD_VERSION} for ${arch} from ${url}`);
|
|
1790
|
+
shellRetry("curl", ["-fL", "--retry", "3", "--retry-delay", "5", "-o", tmpPath, url], { timeout: 60_000 });
|
|
1791
|
+
}
|
|
1792
|
+
catch (err) {
|
|
1793
|
+
console.error(` WARNING: ttyd download failed: ${err instanceof Error ? err.message : String(err)}. Admin terminal will be unavailable.`);
|
|
1794
|
+
console.error(` Remediate: ${remediation}`);
|
|
1795
|
+
try {
|
|
1796
|
+
unlinkSync(tmpPath);
|
|
1797
|
+
}
|
|
1798
|
+
catch { /* nothing to clean */ }
|
|
1799
|
+
return false;
|
|
1800
|
+
}
|
|
1801
|
+
let actualDigest;
|
|
1802
|
+
try {
|
|
1803
|
+
actualDigest = sha256File(tmpPath);
|
|
1804
|
+
}
|
|
1805
|
+
catch (err) {
|
|
1806
|
+
console.error(` WARNING: ttyd — could not read downloaded file ${tmpPath}: ${err instanceof Error ? err.message : String(err)}. Admin terminal will be unavailable.`);
|
|
1807
|
+
try {
|
|
1808
|
+
unlinkSync(tmpPath);
|
|
1809
|
+
}
|
|
1810
|
+
catch { /* nothing to clean */ }
|
|
1811
|
+
return false;
|
|
1709
1812
|
}
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1813
|
+
if (actualDigest !== pinnedDigest) {
|
|
1814
|
+
console.error(` WARNING: ttyd SHA256 mismatch — refusing to install unverified binary.`);
|
|
1815
|
+
console.error(` expected: ${pinnedDigest}`);
|
|
1816
|
+
console.error(` actual: ${actualDigest}`);
|
|
1817
|
+
console.error(` Admin terminal will be unavailable. A later installer version may pin a newer digest.`);
|
|
1818
|
+
try {
|
|
1819
|
+
unlinkSync(tmpPath);
|
|
1820
|
+
}
|
|
1821
|
+
catch { /* nothing to clean */ }
|
|
1822
|
+
return false;
|
|
1823
|
+
}
|
|
1824
|
+
console.log(` ttyd ${TTYD_VERSION} SHA256 verified (${actualDigest.slice(0, 12)}…)`);
|
|
1717
1825
|
try {
|
|
1718
|
-
|
|
1826
|
+
console.log(` [privileged] install ttyd binary to ${TTYD_INSTALL_PATH}`);
|
|
1827
|
+
shell("mv", [tmpPath, TTYD_INSTALL_PATH], { sudo: true });
|
|
1828
|
+
console.log(` [privileged] chmod +x ${TTYD_INSTALL_PATH}`);
|
|
1829
|
+
shell("chmod", ["+x", TTYD_INSTALL_PATH], { sudo: true });
|
|
1719
1830
|
}
|
|
1720
1831
|
catch (err) {
|
|
1721
|
-
console.error(` WARNING: could not
|
|
1832
|
+
console.error(` WARNING: ttyd — could not install to ${TTYD_INSTALL_PATH}: ${err instanceof Error ? err.message : String(err)}. Admin terminal will be unavailable.`);
|
|
1833
|
+
console.error(` Remediate: ${remediation}`);
|
|
1834
|
+
try {
|
|
1835
|
+
unlinkSync(tmpPath);
|
|
1836
|
+
}
|
|
1837
|
+
catch { /* already moved or cleaned */ }
|
|
1838
|
+
return false;
|
|
1839
|
+
}
|
|
1840
|
+
console.log(` ttyd ${TTYD_VERSION} installed at ${TTYD_INSTALL_PATH}`);
|
|
1841
|
+
return true;
|
|
1842
|
+
}
|
|
1843
|
+
function installTerminalService() {
|
|
1844
|
+
log("11", TOTAL, "Installing admin terminal service (ttyd + tmux)...");
|
|
1845
|
+
if (!isLinux()) {
|
|
1846
|
+
console.log(" Skipping admin terminal service (not Linux). On macOS start manually:");
|
|
1847
|
+
console.log(" brew install ttyd tmux && ttyd -p 7681 -i 127.0.0.1 -W tmux new-session -A -s maxy-pty");
|
|
1722
1848
|
return;
|
|
1723
1849
|
}
|
|
1724
|
-
|
|
1725
|
-
//
|
|
1726
|
-
//
|
|
1727
|
-
//
|
|
1728
|
-
|
|
1850
|
+
// ttyd is provisioned from upstream GitHub releases (pinned + SHA256-verified)
|
|
1851
|
+
// because Debian Bookworm's apt does NOT carry a ttyd package (Task 602).
|
|
1852
|
+
// A failure here is loud but non-fatal — the rest of the install completes
|
|
1853
|
+
// and the admin UI degrades to "terminal unavailable" per Task 603.
|
|
1854
|
+
const ttydReady = provisionTtydBinary();
|
|
1855
|
+
// Default ~/.tmux.conf — only written if the operator doesn't already have
|
|
1856
|
+
// one. `history-limit 50000` is load-bearing: a closed-tab + reopen during
|
|
1857
|
+
// an upgrade must show every line the operator missed in scrollback.
|
|
1858
|
+
const homeDir = process.env.HOME ?? "/root";
|
|
1729
1859
|
const tmuxConfDest = resolve(homeDir, ".tmux.conf");
|
|
1730
|
-
if (existsSync(tmuxConfDest)) {
|
|
1860
|
+
if (!existsSync(tmuxConfDest)) {
|
|
1861
|
+
const tmuxConfTemplate = resolve(INSTALL_DIR, "platform/templates/dotfiles/.tmux.conf");
|
|
1731
1862
|
try {
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1863
|
+
if (existsSync(tmuxConfTemplate)) {
|
|
1864
|
+
writeFileSync(tmuxConfDest, readFileSync(tmuxConfTemplate, "utf-8"));
|
|
1865
|
+
console.log(` Wrote default ~/.tmux.conf (history-limit 50000)`);
|
|
1866
|
+
}
|
|
1867
|
+
else {
|
|
1868
|
+
// Fallback if the template was not in the payload for any reason —
|
|
1869
|
+
// preserves the load-bearing scrollback-size guarantee.
|
|
1870
|
+
writeFileSync(tmuxConfDest, "set -g history-limit 50000\n");
|
|
1871
|
+
console.log(` Wrote default ~/.tmux.conf (fallback — template missing)`);
|
|
1737
1872
|
}
|
|
1738
1873
|
}
|
|
1739
1874
|
catch (err) {
|
|
1740
|
-
console.error(` WARNING:
|
|
1875
|
+
console.error(` WARNING: failed to write ~/.tmux.conf: ${err instanceof Error ? err.message : String(err)}`);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
// Install and enable the maxy-ttyd.service --user unit. Independent of
|
|
1879
|
+
// BRAND.serviceName — a single device runs one admin terminal regardless of
|
|
1880
|
+
// brand, because the unit binds to 127.0.0.1:7681 which only one process can
|
|
1881
|
+
// hold anyway. On a multi-brand device, the first brand's install writes the
|
|
1882
|
+
// unit and every subsequent install is a no-op (idempotent overwrite).
|
|
1883
|
+
const systemdUserDir = resolve(homeDir, ".config/systemd/user");
|
|
1884
|
+
mkdirSync(systemdUserDir, { recursive: true });
|
|
1885
|
+
// Skip systemd-unit install if the ttyd binary is not in place — enabling
|
|
1886
|
+
// a unit whose ExecStart points at a missing file just churns systemd with
|
|
1887
|
+
// restart failures.
|
|
1888
|
+
if (!ttydReady) {
|
|
1889
|
+
console.error(" Skipping maxy-ttyd.service install — ttyd binary not present. Admin terminal will be unavailable until remediated.");
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
const ttydUnitTemplate = resolve(INSTALL_DIR, "platform/templates/systemd/maxy-ttyd.service");
|
|
1893
|
+
const ttydUnitDest = join(systemdUserDir, "maxy-ttyd.service");
|
|
1894
|
+
try {
|
|
1895
|
+
if (existsSync(ttydUnitTemplate)) {
|
|
1896
|
+
writeFileSync(ttydUnitDest, readFileSync(ttydUnitTemplate, "utf-8"));
|
|
1741
1897
|
}
|
|
1898
|
+
else {
|
|
1899
|
+
console.error(` WARNING: maxy-ttyd.service template missing at ${ttydUnitTemplate} — admin terminal will not work`);
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
catch (err) {
|
|
1904
|
+
console.error(` WARNING: failed to write ${ttydUnitDest}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1905
|
+
return;
|
|
1742
1906
|
}
|
|
1907
|
+
spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
|
|
1908
|
+
spawnSync("systemctl", ["--user", "enable", "maxy-ttyd"], { stdio: "inherit" });
|
|
1909
|
+
spawnSync("systemctl", ["--user", "restart", "maxy-ttyd"], { stdio: "inherit" });
|
|
1910
|
+
console.log(" maxy-ttyd.service enabled — admin terminal available on 127.0.0.1:7681");
|
|
1743
1911
|
}
|
|
1744
1912
|
function installService() {
|
|
1745
1913
|
log("12", TOTAL, `Starting ${BRAND.productName}...`);
|
|
@@ -1754,6 +1922,7 @@ function installService() {
|
|
|
1754
1922
|
try {
|
|
1755
1923
|
const sysctlConf = "net.core.rmem_max=7340032\nnet.core.wmem_max=7340032\n";
|
|
1756
1924
|
writeFileSync(sysctlTmpPath, sysctlConf);
|
|
1925
|
+
console.log(" [privileged] cp");
|
|
1757
1926
|
shell("cp", [sysctlTmpPath, sysctlDestPath], { sudo: true });
|
|
1758
1927
|
spawnSync("rm", ["-f", sysctlTmpPath]);
|
|
1759
1928
|
spawnSync("sudo", ["sysctl", "--system"], { stdio: "ignore", timeout: 10_000 });
|
|
@@ -1904,6 +2073,7 @@ WantedBy=multi-user.target
|
|
|
1904
2073
|
try {
|
|
1905
2074
|
const tmpPath = "/tmp/wifi-provision.service";
|
|
1906
2075
|
writeFileSync(tmpPath, wifiProvisionService);
|
|
2076
|
+
console.log(" [privileged] cp");
|
|
1907
2077
|
shell("cp", [tmpPath, wifiProvisionPath], { sudo: true });
|
|
1908
2078
|
spawnSync("rm", ["-f", tmpPath]);
|
|
1909
2079
|
spawnSync("sudo", ["systemctl", "daemon-reload"], { stdio: "inherit" });
|
|
@@ -2317,7 +2487,7 @@ try {
|
|
|
2317
2487
|
setupVncViewer();
|
|
2318
2488
|
setupAccount();
|
|
2319
2489
|
installTunnelScripts(); // ~/setup-tunnel.sh, ~/reset-tunnel.sh — the SKILL contract
|
|
2320
|
-
installTerminalService(); // Task
|
|
2490
|
+
installTerminalService(); // Task 657: installs maxy-ttyd.service (ttyd + tmux) for byte-stream admin terminal
|
|
2321
2491
|
installService();
|
|
2322
2492
|
console.log("");
|
|
2323
2493
|
console.log("================================================================");
|
package/package.json
CHANGED
|
@@ -70,9 +70,11 @@ The logs will show which service failed to start and why. Common causes:
|
|
|
70
70
|
|
|
71
71
|
Each Maxy device runs one `--user` systemd unit:
|
|
72
72
|
|
|
73
|
-
- `maxy-ui.service` — the admin + public HTTP server
|
|
73
|
+
- `maxy-ui.service` — the admin + public HTTP server on `127.0.0.1:19199`. Restarted by the upgrade flow; short downtime is expected during steps 8→12 of an upgrade.
|
|
74
|
+
- `maxy-edge.service` — the always-on public listener on the configured port (default 19200). Reverse-proxies HTTP to `maxy-ui`, handles `/websockify` (VNC) and `/ttyd` (admin terminal) WebSocket upgrades locally. Does NOT restart during an upgrade — the browser WebSocket stays connected by construction.
|
|
75
|
+
- `maxy-ttyd.service` — `ttyd` bound to `127.0.0.1:7681`, running `tmux new-session -A -s maxy-pty`. Owns the byte-stream admin terminal rendered by xterm.js in the header overlay and the Software Update modal (Task 657). Independent of `maxy-ui` and `maxy-edge`; outlives service restarts so scrollback is preserved.
|
|
74
76
|
|
|
75
|
-
|
|
77
|
+
If the admin terminal fails to open, check `sudo tail -n 50 ~/.maxy/logs/edge-boot.log` — the `ttyd-ws-upgrade` / `ttyd-proxy-open` / `ttyd-proxy-close` lines carry a `corrId` that ties the full session lifecycle together. For unit health, `systemctl --user status maxy-ttyd` + `journalctl --user -u maxy-ttyd`.
|
|
76
78
|
|
|
77
79
|
## Upgrading
|
|
78
80
|
|
|
@@ -95,6 +95,8 @@ If the initial Cloudflare login fails during setup, Maxy will fall back to askin
|
|
|
95
95
|
|
|
96
96
|
## Software Update click shows an error instead of opening the terminal
|
|
97
97
|
|
|
98
|
+
> **Stale content — Task 657 replaced the VNC-terminal surface with byte-stream xterm.js over `/ttyd`.** The VNC launch-upgrade path described below no longer exists. First-line diagnostic for the new surface: `sudo systemctl --user status maxy-ttyd` plus `sudo grep 'ttyd-proxy' ~/.maxy/logs/edge-boot.log | tail -20`. Failure mode signals: `ttyd-ws-upgrade accepted` with no `ttyd-proxy-open` → `maxy-ttyd.service` is down; `ttyd-proxy-open` with no `ttyd-proxy-chunk dir=upstream→client` → ttyd/tmux is not attaching a PTY. Full rewrite tracked in Task 658. The section below is kept only as a historical reference for devices still on pre-Task-657 bundles.
|
|
99
|
+
|
|
98
100
|
**Symptom:** You clicked **Upgrade** in the Software Update modal, but instead of the VNC terminal overlay appearing, the modal shows a red error row like:
|
|
99
101
|
|
|
100
102
|
```
|